Содержание

  • 1  Получение данных
  • 2  Предобработка данных
    • 2.1  Пропуски
    • 2.2  Дубликаты
    • 2.3  Изменение типа данных и добавление колонок
  • 3  Анализ данных
    • 3.1  События
    • 3.2  Пользователи
    • 3.3  Временной промежуток
    • 3.4  Очищенные данные
      • 3.4.1  События
      • 3.4.2  Пользователи
  • 4  Событийный анализ
    • 4.1  События
    • 4.2  Пользователи
      • 4.2.1  Сколько пользователей совершали событие
      • 4.2.2  Какая доля пользователей проходит на следующий шаг
  • 5  Анализ результатов эксперимента
    • 5.1  А/А тестирование
    • 5.2  A1/B тестирование
    • 5.3  А2/В тестирование
    • 5.4  А/A/B тестирование
    • 5.5  Поправка на множественное тестирование
  • 6  Вывод

Анализ поведения пользователей мобильного приложения¶

Нам предстоит изучить данные стартапа, которы продаёт продукты питания. Наша задача:
Изучить, оттолкнет ли пользователей внесение изменений в шрифты приложения.

Для этого нам предстоит изучить логи событий приложения, которые нам предоставили. Всех пользователей разбили на 3 группы: 2 контрольные со старыми шрифтами и одну экспериментальную — с новыми. Это было сделано для уверенности в точности проведенного тестирования.

Для достижения нашей задачи следует провести следующие действия:

  • Ознакомиться с данными,
  • Провести предобработку данных,
  • Удалить аномалии,
  • Провести событийную аналитику,
  • Провести анализ А/А тестирования,
  • Провести анализ А/А/В тестирования.

Приступим к анализу данных.

Подготовка к работе¶

К содержанию

Загрузим необходимые для работы библиотеки

In [1]:
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats as st
import numpy as np
import math as mth
import datetime as dt
from pandas.plotting import register_matplotlib_converters
import warnings
import plotly.express as px
from plotly import graph_objects as go
from statsmodels.sandbox.stats.multicomp import multipletests

Получение данных¶

К содержанию

Создадим датафрейм и посмотрим, какими данными мы распологаем.

In [2]:
data = pd.read_csv('D:\\Irina\\datasets\\logs_exp.csv', sep='\t')
#выводим все колонки
pd.set_option('display.max_columns', None)
#установим максимальное количество символов в колонке
pd.options.display.max_colwidth = 100
display(data.head(10))
data.info()
EventName DeviceIDHash EventTimestamp ExpId
0 MainScreenAppear 4575588528974610257 1564029816 246
1 MainScreenAppear 7416695313311560658 1564053102 246
2 PaymentScreenSuccessful 3518123091307005509 1564054127 248
3 CartScreenAppear 3518123091307005509 1564054127 248
4 PaymentScreenSuccessful 6217807653094995999 1564055322 248
5 CartScreenAppear 6217807653094995999 1564055323 248
6 OffersScreenAppear 8351860793733343758 1564066242 246
7 MainScreenAppear 5682100281902512875 1564085677 246
8 MainScreenAppear 1850981295691852772 1564086702 247
9 MainScreenAppear 5407636962369102641 1564112112 246
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 244126 entries, 0 to 244125
Data columns (total 4 columns):
 #   Column          Non-Null Count   Dtype 
---  ------          --------------   ----- 
 0   EventName       244126 non-null  object
 1   DeviceIDHash    244126 non-null  int64 
 2   EventTimestamp  244126 non-null  int64 
 3   ExpId           244126 non-null  int64 
dtypes: int64(3), object(1)
memory usage: 7.5+ MB

Итак, у нас есть лог приложения. Каждая запись в логе — это действие пользователя, или событие. Всего у нас есть 4 колонки:

  • EventName — название события;
  • DeviceIDHash — уникальный идентификатор пользователя;
  • EventTimestamp — время события;
  • ExpId — номер эксперимента: 246 и 247 — контрольные группы, а 248 — экспериментальная.

В датафрейме есть 244126 записей, и кажется, нет пропусков. Первое, что бросается в глаза - даты представлены в формате timestamp с типом int. Для работы нам этот вариант не подходит, поэтому позднее мы поправим этот момент. Приступим к предобработке.

Предобработка данных¶

К содержанию

Прежде чем мы приступим к обработке данных, изменим названия колонок в соответствии со стилем Snake case. Сделаем это для удобства работы.

In [3]:
data.columns = ['event_name','device_id','event_timestamp','exp_id']
data.head()
Out[3]:
event_name device_id event_timestamp exp_id
0 MainScreenAppear 4575588528974610257 1564029816 246
1 MainScreenAppear 7416695313311560658 1564053102 246
2 PaymentScreenSuccessful 3518123091307005509 1564054127 248
3 CartScreenAppear 3518123091307005509 1564054127 248
4 PaymentScreenSuccessful 6217807653094995999 1564055322 248

Пропуски¶

К содержанию

Предварительно мы сделали предположение, что в данных нет пропусков, проверим данное утверждение.

In [4]:
data.isna().sum()
Out[4]:
event_name         0
device_id          0
event_timestamp    0
exp_id             0
dtype: int64

Что же, у нас нет пропусков, это несомненно радует, потому что заменить пропуски не представлялось бы возможным из-за особенностей данных.

Дубликаты¶

К содержанию

Проверим наличие дубликатов в датафрейме, как явных, так и неявных.

In [5]:
data.duplicated().sum()
Out[5]:
413

Итак, у нас есть 413 полных дубликатов, это составляет 0.17% от всего количества данных, так что смело избавимся от этих дубликатов, сохранив первое "вхождение" данных. И проверим, есть ли неявные дубликаты.

In [6]:
#Удалим дубликаты с обновлением индексов
data.drop_duplicates(keep='first').reset_index()
#Проверим наличие неявных дубликатов, оценив наличие ошибок в наименованиях события
data['event_name'].unique()
Out[6]:
array(['MainScreenAppear', 'PaymentScreenSuccessful', 'CartScreenAppear',
       'OffersScreenAppear', 'Tutorial'], dtype=object)

Дубликатов, образовавшихся из-за орфографических ошибок или расхождений регистра нет. Значит двигаемся дальше.

Изменение типа данных и добавление колонок¶

К содержанию

Итак, пришло время решить проблему с датами. Для этого создадим два новых столбца, в которые войдут дата и время и отдельно только дата. Для этого нужно перевести данные из формата timestamp в привычный нам формат даты.

In [7]:
data['event_dt'] = pd.to_datetime(data['event_timestamp'], unit='s')
data['date'] = data['event_dt'].dt.floor('D')
data.head()
Out[7]:
event_name device_id event_timestamp exp_id event_dt date
0 MainScreenAppear 4575588528974610257 1564029816 246 2019-07-25 04:43:36 2019-07-25
1 MainScreenAppear 7416695313311560658 1564053102 246 2019-07-25 11:11:42 2019-07-25
2 PaymentScreenSuccessful 3518123091307005509 1564054127 248 2019-07-25 11:28:47 2019-07-25
3 CartScreenAppear 3518123091307005509 1564054127 248 2019-07-25 11:28:47 2019-07-25
4 PaymentScreenSuccessful 6217807653094995999 1564055322 248 2019-07-25 11:48:42 2019-07-25

Теперь все данные приведены в порядок и мы можем приступить к анализу данных.

Анализ данных¶

К содержанию

Теперь проанализируем, сколько у нас есть событий, пользователей. Какой период времени нам предоставлен. Оценим, есть ли аномальные данные и избавимся от них.

События¶

К содержанию

In [8]:
print('Общее количество событий:', len(data['event_name']))
display('Количество каждого вида событий:', data['event_name'].value_counts())
data['event_name'].value_counts().plot(kind='pie', 
                                       ylabel='', 
                                       autopct='%1.1f%%',
                                       figsize=(10,10),
                                       fontsize=12,
                                       colormap = 'Accent');
plt.title('Соотношение видов событий', fontsize=16);
Общее количество событий: 244126
'Количество каждого вида событий:'
MainScreenAppear           119205
OffersScreenAppear          46825
CartScreenAppear            42731
PaymentScreenSuccessful     34313
Tutorial                     1052
Name: event_name, dtype: int64

Итак, за исследуемое время было совершено 244126 событий. Всего у нас представлено 5 уникальных событий:

  • MainScreenAppear - открытие главной страницы приложения - самое популярное событие, что не удивительно, ведь без этого невозможно что-либо сделать в приложении. Его доля от остальных событий составляет 48.8%;
  • OffersScreenAppear - экран продукта - второй по популярности - пользователи изучают подробности продуктов и после могут добавить их в корзину;
  • CartScreenAppear - Окно корзины - третье по популярности событие;
  • PaymentScreenSuccesful - экран успешной оплаты покупки;
  • Tutorial - инструкция по использованию приложения - самое непопулярное событие, меньше всего пользователей изучают, как пользоваться приложением.

Пользователи¶

К содержанию

In [9]:
print('Общее количество уникальных пользователей:', data['device_id'].nunique())
user_data = data.groupby('exp_id').agg({'device_id':'nunique'}).reset_index()
display(user_data)
user_data.plot.bar(x='exp_id', 
                   y='device_id',
                   legend=False,
                   rot=0,
                   color=['lightgreen','navajowhite','saddlebrown'],
                   xlabel='Номер эксперимента',
                   ylabel='Количество пользователей')
plt.title('Количество уникальных пользователей в эксперименте');
Общее количество уникальных пользователей: 7551
exp_id device_id
0 246 2489
1 247 2520
2 248 2542

В исследуемый момент приложением пользуется 7551 пользователь. Всего пользователи разделены на 3 группы, в каждой из которой есть около 2.5 тысяч пользователей. Оценим, сколько событий совершают пользователи.

In [10]:
events_per_user = data.pivot_table(index='device_id',values='event_name',aggfunc='count')
print('В среднем на пользователя приходится {} события'.format(round(events_per_user['event_name'].mean())))
events_per_user.hist(bins=50, color='Orange')
plt.title('Число событий на пользователя')
plt.xlabel('Число событий')
plt.ylabel('Число пользователей');
display(events_per_user.describe())
В среднем на пользователя приходится 32 события
event_name
count 7551.000000
mean 32.330287
std 65.312344
min 1.000000
25% 9.000000
50% 20.000000
75% 37.500000
max 2308.000000

Большая часть пользователей совершает не более 37 событий. В среднем пользователи совершают около 32 событий. Однако, настораживает, что какие-то пользователи совершают около 2300 событий. Конечно всё возможно, может кто-то завел корпоративный аккаунт и заказывает обеды в компанию, или это аккаунт какого-нибудь кафе, которое заказывает продукты для готовки. Согласно графику таких пользователей крайне мало. С точностью сказать, являются ли эти данные аномальными, нельзя. Посмотрим чуть поближе, "срезав" слишком большое количество событий.

In [11]:
events_per_user.query('event_name < 100')['event_name'].hist(bins=25, color='Green')
plt.title('Число событий на пользователя')
plt.xlabel('Число событий')
plt.ylabel('Число пользователей')
plt.axvline(x=32,color='black',linestyle='--');

При более детальном взгляде, можно отметить, что основная масса пользователей совершает около 20 событий, и крайне мало пользователей доходят до 100 событий.

Временной промежуток¶

К содержанию

Теперь изучим, данные за какой период представлены в логе.

In [12]:
print('Первая дата события:', data['date'].min(),
      '\nПоследняя дата события:', data['date'].max())
Первая дата события: 2019-07-25 00:00:00 
Последняя дата события: 2019-08-07 00:00:00

Итак, в нашем распоряжении есть две недели, но стоит проверить, есть ли данные на протяжении всех 14 дней.

In [13]:
data['date'].hist(bins=50, figsize=(15,5))
plt.xlabel('Дата')
plt.ylabel('Количество событий')
plt.title('Распределение событий по датам');

Что же, полные данные присутствуют только в последние 7 дней логов - с 1 по 7 августа 2019 года включительно. Теперь стоит изучить, есть ли данные всех групп в этот промежуток.

In [14]:
group_data = data.pivot_table(values='event_name', index='date',columns='exp_id', aggfunc='count')
group_data.columns = ['246','247','248']
group_data.plot.barh(figsize=(6,10), xlabel='Дата', colormap='Pastel2')
plt.xlabel('Количество событий')
plt.title('Количество событий в зависимости от времени в разрезе групп');

Отлично, во все интересующие нас дни анализа присутствовали пользователи всех трёх групп. Теперь пришло время избавиться от аномалий.

Очищенные данные¶

К содержанию

Для того, чтобы избавиться от аномалий, мы сделаем срез нашего датафрейма и удалим данные за первые семь дней, так как они неполные и не интересуют нас.

In [15]:
clear_data = data.query('date > "2019-07-31"').reset_index(drop=True)
clear_data.head()
Out[15]:
event_name device_id event_timestamp exp_id event_dt date
0 Tutorial 3737462046622621720 1564618048 246 2019-08-01 00:07:28 2019-08-01
1 MainScreenAppear 3737462046622621720 1564618080 246 2019-08-01 00:08:00 2019-08-01
2 MainScreenAppear 3737462046622621720 1564618135 246 2019-08-01 00:08:55 2019-08-01
3 OffersScreenAppear 3737462046622621720 1564618138 246 2019-08-01 00:08:58 2019-08-01
4 MainScreenAppear 1433840883824088890 1564618139 247 2019-08-01 00:08:59 2019-08-01

События¶

К содержанию

In [16]:
print('Количество строк до фильтрации:', len(data),
      '\nКоличество строк после фильтрации:', len(clear_data))
print('Удалено {} строк, что составляет {:.2%}'.format((len(data)-len(clear_data)),
      (len(data) - len(clear_data)) / len(data)))
Количество строк до фильтрации: 244126 
Количество строк после фильтрации: 241298
Удалено 2828 строк, что составляет 1.16%

Количество событий сократилось чуть больше, чем на 1%. Это здорово, наше вмешательство не будет вносить серьезных изменений.

Пользователи¶

К содержанию

In [17]:
print('Количество уникальных пользователей до фильтрации:', data['device_id'].nunique(),
      '\nКоличество уникальных пользователей после фильтрации:', clear_data['device_id'].nunique())
print('Удалено {} пользователей, что составляет {:.2%}'.format((data['device_id'].nunique()-clear_data['device_id'].nunique()),
      (data['device_id'].nunique() - clear_data['device_id'].nunique()) / data['device_id'].nunique()))
Количество уникальных пользователей до фильтрации: 7551 
Количество уникальных пользователей после фильтрации: 7534
Удалено 17 пользователей, что составляет 0.23%

Количество пользователей сократилось менее чем на 0.5%, скорее всего это пользователи, которые попали случайно из других исследований, или из-за того, что взят неправильный временной промежуток.

Событийный анализ¶

К содержанию

Когда мы убедились, что все данные в порядке, приступим к анализу воронки событий.

События¶

К содержанию

Вновь взглянем на наши события.

In [18]:
print('Общее количество событий:', len(clear_data['event_name']))
display('Количество каждого вида событий:', clear_data['event_name'].value_counts())
clear_data['event_name'].value_counts().plot(kind='pie', 
                                       ylabel='', 
                                       autopct='%1.1f%%',
                                       figsize=(9,9),
                                       fontsize=12,
                                       colormap = 'Accent');
plt.title('Соотношение видов событий', fontsize=16);
Общее количество событий: 241298
'Количество каждого вида событий:'
MainScreenAppear           117431
OffersScreenAppear          46350
CartScreenAppear            42365
PaymentScreenSuccessful     34113
Tutorial                     1039
Name: event_name, dtype: int64

Итак, теперь перед нами 241298 событий, часть нам пришлось удалить на этапе очистки от аномалий. У нас по-прежнему есть 5 уникальных событий.

  1. MainScreenAppear - Самое часто совершаемое событие, без него практически невозможно совершить следующие события - 48.7%;
  2. OffersScreenAppear - экран продукта для заказа на втором месте - 19.2%;
  3. CartScreenAppear - экран корзины заказов - 17.6%;
  4. PaymentScreenSuccesful - экран успешной оплаты покупки - 14.1%
  5. Tutorial - инструкция по использованию приложения - 0.4%.

Доли событий почти не изменились.

Пользователи¶

К содержанию

Теперь оценим, какие события выполняют пользователи.

Сколько пользователей совершали событие¶

К содержанию

In [19]:
user_logs = (clear_data.pivot_table(index='event_name',values='device_id',aggfunc='nunique')
        .sort_values(by='device_id',ascending=False))
user_logs['at_least_1_time_%'] = round((user_logs['device_id']/clear_data['device_id'].nunique())*100,2)
display(user_logs)
device_id at_least_1_time_%
event_name
MainScreenAppear 7419 98.47
OffersScreenAppear 4593 60.96
CartScreenAppear 3734 49.56
PaymentScreenSuccessful 3539 46.97
Tutorial 840 11.15

Итак, всего у нас есть 7534 пользователя, из них только 98.36% (7419 пользователей) заходят на главный экран. Около 60% пользователей переходят на экран продукта для заказа. И только половина пользователей заходит в корзину или совершает оплату покупки. Меньше всего пользователей изучают, как работать с приложением. В таблице в графе at_least_1_time_% указано какой процент пользователей совершает событие хотябы раз.

Какая доля пользователей проходит на следующий шаг¶

К содержанию

Предположим, что пользователи совершают следующую последовательность действий:

  1. Открывают главный экран приложения,
  2. Выбирают продукт, который они хотят заказать и добавляют в корзину,
  3. Переходят в корзину,
  4. Оплачивают заказ.

Таким образом мы получим следующую последовательность событий: MainScreenAppear -> OffersScreenAppear -> CartScreenAppear -> PaymentScreenSuccessful. Событие Tutorial не попадает в данную последовательность действий, так как оно может быть совершено пользователем в любой момент времени и от него не зависит совершение ключевого события - успешной оплаты заказа. Таким образом, изучим получившуюся воронку событий, без учета события Tutorial.

In [20]:
event_funnel = (clear_data.query('event_name !="Tutorial"')
                .pivot_table(index='event_name',values='device_id',aggfunc='nunique')
                .sort_values(by='device_id',ascending=False))

event_funnel['conversion_per_step'] = 0
for i in range(0, len(event_funnel['device_id'])):
    if i == 0:
        event_funnel['conversion_per_step'].iloc[i] = 100
    else:
        event_funnel['conversion_per_step'].iloc[i] = round(
            (event_funnel['device_id'].iloc[i] / event_funnel['device_id'].iloc[i-1])*100, 2)
event_funnel
Out[20]:
device_id conversion_per_step
event_name
MainScreenAppear 7419 100.00
OffersScreenAppear 4593 61.91
CartScreenAppear 3734 81.30
PaymentScreenSuccessful 3539 94.78

Итак, мы выяснили, что на экран заказа продукта переходит только около 62% пользователей. Уже на этом этапе теряются пользователи. Из них 81% переходит в корзину. И почти 95% пользователей, которые перешли в корзину, совершают покупки.

In [21]:
print('От первого события до оплаты доходят {:.2%} пользователей'.format(
    event_funnel['device_id'].iloc[3]/event_funnel['device_id'].iloc[0]))
От первого события до оплаты доходят 47.70% пользователей

Всего, от запуска приложения (просмотра главного экрана) до покупки в приложении доходят только 47,7% пользователей. Чуть меньше половины. Да, не идеальный показатель, но все равно очень хороший. С этим можно работать. Конечно мы не знаем других метрик приложения и не можем оценить его эффективность. Но в данный момент нас интересует, не отпугнёт ли наше нововведение пользователей и не уменьшит ли показатель конверсии.

Визуализируем воронку событий.

In [22]:
fig = go.Figure(go.Funnel(
    y = ['MainScreenAppear','OffersScreenAppear','CartScreenAppear','PaymentScreenSuccessful'],
    x = event_funnel['device_id'],
    textposition = "inside",
    textinfo = "value+percent previous"))
fig.update_layout(
    title='Воронка событий',
    yaxis_title='События',
    autosize=False,
    width=1000,
    height=500,
    font=dict(
        family="arial",
        size=18,
        color="darkslateblue"
    ))
fig.show()

Теперь визуализируем её по группам

In [23]:
group_event_funnel = (clear_data.query('event_name != "Tutorial"')
                      .pivot_table(index='event_name', values='device_id',columns='exp_id', aggfunc='nunique')
                      .reset_index()
                      .sort_values(by=[246], ascending=False))

fig = go.Figure()

fig.add_trace(go.Funnel(
    name = 'Группа 246',
    y = ['MainScreenAppear','OffersScreenAppear','CartScreenAppear','PaymentScreenSuccessful'],
    x = group_event_funnel[246],
    textinfo = "value+percent previous"))

fig.add_trace(go.Funnel(
    name = 'Группа 247',
    orientation = "h",
    y = ['MainScreenAppear','OffersScreenAppear','CartScreenAppear','PaymentScreenSuccessful'],
    x = group_event_funnel[247],
    textposition = "inside",
    textinfo = "value+percent previous"))

fig.add_trace(go.Funnel(
    name = 'Группа 248',
    orientation = "h",
    y = ['MainScreenAppear','OffersScreenAppear','CartScreenAppear','PaymentScreenSuccessful'],
    x = group_event_funnel[248],
    textposition = "inside",
    textinfo = "value+percent previous"))

fig.update_layout(
    title='Воронка событий по группам',
    yaxis_title='События',
    autosize=False,
    width = 1000,
    height=500,
    font=dict(
        family="arial",
        size=18,
        color="darkslateblue"
    ))

fig.show()

Анализ результатов эксперимента¶

К содержанию

Для начала проверим, сколько у нас есть пользователей в каждой исследуемой группе после очистки данных.

In [24]:
user_clear_data = clear_data.groupby('exp_id').agg({'device_id':'nunique'})
display(user_clear_data)
user_clear_data.plot.bar(
                   y='device_id',
                   legend=False,
                   rot=0,
                   color=['lightgreen','navajowhite','saddlebrown'],
                   xlabel='Номер эксперимента',
                   ylabel='Количество пользователей')
plt.title('Количество уникальных пользователей в эксперименте');
device_id
exp_id
246 2484
247 2513
248 2537

Проверим, встречаются ли одни и те же уникальные пользователи в разных группах, чтобы избежать проблемы подглядывания.

In [25]:
#Получим таблицу со списками уникальных пользователей по группам
user_list = clear_data.groupby('exp_id')['device_id'].unique()
#Получим списки, в которые будут добавляться значениия, встречающихся в обоих списках
list_of_repeats1 = list(set(user_list[246]) & set(user_list[247]))
list_of_repeats2 = list(set(user_list[247]) & set(user_list[248]))
list_of_repeats3 = list(set(user_list[246]) & set(user_list[248]))
#Объедиим их в один список
common_repeats = [*list_of_repeats1, *list_of_repeats2, *list_of_repeats3]
#Зададим условие, проверяющее длину списка
if len(common_repeats) == 0:
    print('Уникальные пользователи в группах не пересекаются')
else:
    print('Идентификаторы пользователей, которых стоит удалить:', common_repeats)
Уникальные пользователи в группах не пересекаются

Разница между "сырыми" и очищенными данными минимальна, ведь мы избавились только от 17 пользователей и в каждой группе около 2500 пользователей. Каждый пользователь принадлежит только одной группе. Теперь оценим сколько пользователей в каждой группе совершали определенные события

In [26]:
exp_groups = clear_data.pivot_table(index='event_name', values='device_id',columns='exp_id', aggfunc='nunique')
display(exp_groups)
exp_groups.plot(kind='barh', xlabel='Событие')
plt.title('Количество пользователей, совершивших событие в зависимости от группы')
plt.xlabel('Количество пользователей');
exp_id 246 247 248
event_name
CartScreenAppear 1266 1238 1230
MainScreenAppear 2450 2476 2493
OffersScreenAppear 1542 1520 1531
PaymentScreenSuccessful 1200 1158 1181
Tutorial 278 283 279

В каждой группе примерно одинаковое количество пользователей совершает схожие события. Самым популярным событием в каждой группе является переход на главный экран приложения, его совершают почти все пользователи каждой группы. Вычислим, какая доля пользователей в каждой группе совершала это действие. Для этого немного видоизменим нашу таблицу.

In [27]:
#"Развернем" полученную ранее таблицу
event_by_experiment = exp_groups.T
#Добавим недостающие данные - общее число пользователей
event_by_experiment['total_users'] = user_clear_data['device_id']
event_by_experiment
Out[27]:
event_name CartScreenAppear MainScreenAppear OffersScreenAppear PaymentScreenSuccessful Tutorial total_users
exp_id
246 1266 2450 1542 1200 278 2484
247 1238 2476 1520 1158 283 2513
248 1230 2493 1531 1181 279 2537

Теперь, когда с таблицей работать стало удобнее, посчитаем доли пользователей.

In [28]:
print('Доля пользователей, открывших главный экран:',
      '\nВ группе 246: {:.2%}'.format(
          event_by_experiment['MainScreenAppear'][246]/event_by_experiment['total_users'][246]),
      '\nВ группе 247: {:.2%}'.format(
          event_by_experiment['MainScreenAppear'][247]/event_by_experiment['total_users'][247]),
      '\nВ группе 248: {:.2%}'.format(
          event_by_experiment['MainScreenAppear'][248]/event_by_experiment['total_users'][248]))
Доля пользователей, открывших главный экран: 
В группе 246: 98.63% 
В группе 247: 98.53% 
В группе 248: 98.27%

Итак, во всех группах самое популярное событие совершают около 98% пользователей.
Настало время сравнить, есть ли статистически значимые различия между этими группами в совершении определенных событий. Но для начала, чтобы провести тест, добавим в нашу таблицу строку с объединенными данными по двум контрольным группам - 246 и 247. Назовём полученную группу 493 (как сумма 246 и 247), чтобы было удобно с ней работать.

In [29]:
event_by_experiment.loc[493] = event_by_experiment.loc[246] + event_by_experiment.loc[247]
event_by_experiment
Out[29]:
event_name CartScreenAppear MainScreenAppear OffersScreenAppear PaymentScreenSuccessful Tutorial total_users
exp_id
246 1266 2450 1542 1200 278 2484
247 1238 2476 1520 1158 283 2513
248 1230 2493 1531 1181 279 2537
493 2504 4926 3062 2358 561 4997

Проводить оценку статистической значимости мы будем с использованием z-теста для сравнения долей выборок, так как наши выборки не совсем идентичны, а поведение людей трудно поддаётся нормальному распределению.

Так как нам предстоит проверить несколько выборок по нескольким параметрам, постараемся максимально автоматизировать процесс. Для начала зададим функцию, которая будет автоматизировать проведение z-теста.

In [30]:
#Функция, которая проводит z-тестирование
def z_test(hits, trials, alpha):
    # доля успехов в исследуемых группах:
    p1 = hits[0]/trials[0]
    p2 = hits[1]/trials[1]

    # доля успехов в комбинированном датасете:
    p_combined = (hits[0] + hits[1]) / (trials[0] + trials[1])

    # разница долей в датасетах
    difference = p1 - p2 

    # считаем статистику в ст.отклонениях стандартного нормального распределения
    z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))

    # задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
    distr = st.norm(0, 1)  

    p_value = (1 - distr.cdf(abs(z_value))) * 2

    print('p-значение: ', p_value)

    if p_value < alpha:
        print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
    else:
        print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными') 
    #вернем значение p_value для поправок на множественное тестирование
    return p_value

Теперь зададим функцию, которая будет передавать функции, которая выполняет тестирование, параметры для проверки. В тело функции зададим списки с количествами пользователей тестируемых групп для каждого события, которые будут принимать значение одной группы и второй сравниваемой группы.

In [31]:
#Создадим функцию, которая будет проводить z-тест для всех параметров по группам
#На вход будет принимать номера экспериментов и значение статистической значимости
def lazy_check (n_1, n_2, alpha):
    #Зададим переменные, которые будут принимать значения p_value для поправок на множественное
    #проведение гипотез
    pv1 = 0
    pv2 = 0
    pv3 = 0
    pv4 = 0
    #Зададим список значений trials для обоих экспериментов (общее количество пользователей каждого эксперимента)
    n_trials = ([event_by_experiment['total_users'][n_1],
                 event_by_experiment['total_users'][n_2]])
    #Теперь получим списки значений hits по каждой исследуемой выборке
    main_screen = ([event_by_experiment['MainScreenAppear'][n_1],
                    event_by_experiment['MainScreenAppear'][n_2]])
    
    offer_screen = ([event_by_experiment['OffersScreenAppear'][n_1],
                     event_by_experiment['OffersScreenAppear'][n_2]])
    
    cart_screen = ([event_by_experiment['CartScreenAppear'][n_1], 
                    event_by_experiment['CartScreenAppear'][n_2]])
    
    payment = ([event_by_experiment['PaymentScreenSuccessful'][n_1], 
                event_by_experiment['PaymentScreenSuccessful'][n_2]])
    
    #Автоматизируем вывод результатов тестирования
    print('Сравнение долей по пользователям, открывшим главную страницу:')
    pv1 = z_test(main_screen, n_trials, alpha)
    print('')
    print('Сравнение долей по пользователям, открывшим страницу товара:')
    pv2 = z_test(offer_screen, n_trials, alpha)
    print('')
    print('Сравнение долей по пользователям, перешедшим в корзину:')
    pv3 = z_test(cart_screen, n_trials, alpha)
    print('')
    print('Сравнение долей по пользователям, совершившим покупку:')
    pv4 = z_test(payment, n_trials, alpha)
    #Вернем список p_value
    return [pv1, pv2, pv3, pv4]

Теперь нам необходимо провести поочередную попарную проверку наших выборок.

  • Для начала проведем А/А - тестирование и сверим контрольные выборки. Не имеют ли они различий. Если в них нет различий, значит, скорее всего нам удастся избежать ошибок.
  • Затем нам следует поочередно сверить каждую из контрольных выборок с экспериментальной
  • Финальным этапом будет проведение сравнения объединенных контрольных групп (мы дали этой группе название 493) c экспериментальной выборкой.

Для всех тестирований ниже примем следующие гипотезы:

  • Н0: Среднее количество пользователей совершивших событие в группах А и В (А1 и А2) равно
  • Н1: Среднее количество пользователей совершивших событие в группах А и В (А1 и А2) не равно

А/А тестирование¶

К содержанию

Сравним группы 246 и 247 со старыми шрифтами между собой. Чтобы исключить возникновение ошибок.

In [32]:
# Сделаем проверку контрольных групп А/A
#Получим лист p_value 
lp1_05 = 0
lp1_05 = lazy_check(246, 247, 0.05)
Сравнение долей по пользователям, открывшим главную страницу:
p-значение:  0.7570597232046099
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, открывшим страницу товара:
p-значение:  0.2480954578522181
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, перешедшим в корзину:
p-значение:  0.22883372237997213
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, совершившим покупку:
p-значение:  0.11456679313141849
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Для начала мы задали статистическую значимость в 5%. То есть с вероятностью в 5% мы можем ошибочно отвергнуть гипотезу. Тестирование показало, что во всех случаях исследуемые доли в обеих выборках не имеют статистически значимых различий. Значит можно считать выборки равными и переходить к А/В - тестированию.
Но перед этим проверим, есть ли вероятность отвергнуть нулевую гипотезу при статистической значимости в 10%.

In [33]:
lp1_10 = 0
lp1_10 = lazy_check(246, 247, 0.1)
Сравнение долей по пользователям, открывшим главную страницу:
p-значение:  0.7570597232046099
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, открывшим страницу товара:
p-значение:  0.2480954578522181
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, перешедшим в корзину:
p-значение:  0.22883372237997213
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, совершившим покупку:
p-значение:  0.11456679313141849
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

В данной ситуации мы также не отвергаем нулевую гипотезу, значит мы смело можем продолжать наше тестирование.

A1/B тестирование¶

К содержанию

Теперь перейдём к сравнению контрольных выборок с экспериментальной. Начнем с выборки 246 (примем ее а А1). Проведем тестирование сразу с двумя вариантами статистической значимости.

In [34]:
#Сделаем проверку контрольной группы 246 с экспериментальной 248
lp2_05 = 0
lp2_05 = lazy_check(246, 248, 0.05)
Сравнение долей по пользователям, открывшим главную страницу:
p-значение:  0.2949721933554552
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, открывшим страницу товара:
p-значение:  0.20836205402738917
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, перешедшим в корзину:
p-значение:  0.07842923237520116
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, совершившим покупку:
p-значение:  0.2122553275697796
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Итак, при статичтической значимости в 5% разницы между проверяемыми гипотезами нет.

In [35]:
lp2_10 = 0
lp2_10 = lazy_check(246, 248, 0.1)
Сравнение долей по пользователям, открывшим главную страницу:
p-значение:  0.2949721933554552
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, открывшим страницу товара:
p-значение:  0.20836205402738917
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, перешедшим в корзину:
p-значение:  0.07842923237520116
Отвергаем нулевую гипотезу: между долями есть значимая разница

Сравнение долей по пользователям, совершившим покупку:
p-значение:  0.2122553275697796
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

А вот при статистической значимости в 10%, наблюдаются различия в выборках по показателю перехода пользователей в корзину. Посмотрим, как отличаются эти группы.

In [36]:
print('В контрольной группе {:.2%} пользователей переходят в корзину'.format(
    event_by_experiment['CartScreenAppear'].loc[246]/event_by_experiment['total_users'].loc[246]),
     '\nВ экспериментальной группе {:.2%} пользователей переходят в корзину'. format(
         event_by_experiment['CartScreenAppear'].loc[248]/event_by_experiment['total_users'].loc[248]))
В контрольной группе 50.97% пользователей переходят в корзину 
В экспериментальной группе 48.48% пользователей переходят в корзину

В экспериментальной группе пользователи менее активно переходят в корзину и это имеет статистическую значимость. Однако, это не влияет на совершение покупок в контрольной группе. При статистической значимости в 5% выборки не отличаются.

А2/В тестирование¶

К содержанию

Теперь проведем сравнение второй контрольной группой с экспериментальной. Также проведем с двумя разными значениями статистической значимости.

In [37]:
#Сделаем проверку контрольной группы 247 с экспериментальной 248
lp3_05 = 0
lp3_05 = lazy_check(247, 248, 0.05)
Сравнение долей по пользователям, открывшим главную страницу:
p-значение:  0.4587053616621515
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, открывшим страницу товара:
p-значение:  0.9197817830592261
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, перешедшим в корзину:
p-значение:  0.5786197879539783
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, совершившим покупку:
p-значение:  0.7373415053803964
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

В данном случае разницы между группами не отмечается.

In [38]:
lp3_10 = 0
lp3_10 = lazy_check(247, 248, 0.1)
Сравнение долей по пользователям, открывшим главную страницу:
p-значение:  0.4587053616621515
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, открывшим страницу товара:
p-значение:  0.9197817830592261
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, перешедшим в корзину:
p-значение:  0.5786197879539783
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, совершившим покупку:
p-значение:  0.7373415053803964
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

И в случае 10% статистической значимости разницы между группами нет. А вот в группе 246 почему-то есть различия, хотя они не влияют на ключевое событие. Теперь перейдем к сравнению экспериментальной группы с объединенными контрольными группами.

А/A/B тестирование¶

К содержанию

И наконец, мы переходим к тестированию объединенных контрольных групп с экспериментальной.

In [39]:
#Сравним объединенную контрольную выборку 493 с экспериментальной 248
lp4_05 = 0
lp4_05 = lazy_check(493, 248, 0.05)
Сравнение долей по пользователям, открывшим главную страницу:
p-значение:  0.29424526837179577
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, открывшим страницу товара:
p-значение:  0.43425549655188256
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, перешедшим в корзину:
p-значение:  0.18175875284404386
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, совершившим покупку:
p-значение:  0.6004294282308704
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

При статистической значимости в 5% разницы между исследуемыми выборками не наблюдается.

In [40]:
lp4_10 = 0
lp4_10 = lazy_check(493, 248, 0.1)
Сравнение долей по пользователям, открывшим главную страницу:
p-значение:  0.29424526837179577
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, открывшим страницу товара:
p-значение:  0.43425549655188256
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, перешедшим в корзину:
p-значение:  0.18175875284404386
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Сравнение долей по пользователям, совершившим покупку:
p-значение:  0.6004294282308704
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

И при значимости в 10% разницы также не наблюдается, значит мы можем передать дизайнерскому отделу, что они смело могут менять шрифты.

Поправка на множественное тестирование¶

К содержанию

Так как мы сравниваем более чем 2 группы, к тому же по нескольким изменениям (в нашем случае доли пользователей, которые побывали на конкретном этапе), стоит учесть поправки на множественное тестирование. Таким образом, мы сможем избежать возникновения ошибок и скорректировать уровень статистической значимости.
Для этого мы проверим наши проведенные тесты на два типа ошибок:

  • FWER (Family-Wise Error Rate) - Групповая вероятность ошибки, которая представляет собой вероятность получить по крайней мере одну ошибку первого рода
  • FDR (False Discovery Rate) — это среднее значение отношения ошибок первого рода к общему количеству отклонений основной гипотезы
In [41]:
#Создадим списки со всеми значениями p_value для каждого уровня статистической значимости
pv_05 = [*lp1_05, *lp2_05, *lp3_05, *lp4_05]
pv_10 = [*lp1_10, *lp2_10, *lp3_10, *lp4_10]

#Вероятность получить хотя бы одну ошибку первого рода для двух значений alpha
print("FWER: " + str(multipletests(sorted(pv_05), alpha=0.05, 
                     method='holm', is_sorted = True))) 
print("FWER: " + str(multipletests(sorted(pv_10), alpha=0.1, 
                     method='holm', is_sorted = True))) 
print('')

#Среднее значение отношения ошибок первого рода к общему количеству отклонений основной гипотезы
print("FDR: " + str(multipletests(pv_05, alpha=0.05, 
                    method='fdr_bh', is_sorted = False))) 
print("FDR: " + str(multipletests(pv_10, alpha=0.1, 
                    method='fdr_bh', is_sorted = False))) 
FWER: (array([False, False, False, False, False, False, False, False, False,
       False, False, False, False, False, False, False]), array([1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.]), 0.0032006977101884937, 0.003125)
FWER: (array([False, False, False, False, False, False, False, False, False,
       False, False, False, False, False, False, False]), array([1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.]), 0.006563398416385313, 0.00625)

FDR: (array([False, False, False, False, False, False, False, False, False,
       False, False, False, False, False, False, False]), array([0.80753037, 0.52439501, 0.52439501, 0.52439501, 0.52439501,
       0.52439501, 0.52439501, 0.52439501, 0.6672078 , 0.91978178,
       0.73899007, 0.80753037, 0.52439501, 0.6672078 , 0.52439501,
       0.73899007]), 0.0032006977101884937, 0.003125)
FDR: (array([False, False, False, False, False, False, False, False, False,
       False, False, False, False, False, False, False]), array([0.80753037, 0.52439501, 0.52439501, 0.52439501, 0.52439501,
       0.52439501, 0.52439501, 0.52439501, 0.6672078 , 0.91978178,
       0.73899007, 0.80753037, 0.52439501, 0.6672078 , 0.52439501,
       0.73899007]), 0.006563398416385313, 0.00625)

Использованный метод выводит результат проведения теста в виде буллевых значений, где:

  • True - нулевая гипотеза отвергается,
  • False - нулевая гипотеза не отвергается;

Затем выводится список скорректированных значений p_value для каждого проведенного теста, а также скорректированные значения для уровня статистической значимости по двум методам поправок - Сидака и Бонферрони.

Исходя из полученных нами результатов, ни в одном из тестов нулевая гипотеза не отвергается и стоит использовать уровень статистической значимости в 0.3 - 0.6%, чтобы снизить уровень ложноположительных результатов.

Вывод¶

К содержанию

Мы исследовали данные трех групп, пользовавшихся приложением по продаже продуктов питания. Мы выделили следующие особенности данных:

  • Пользователи разделены на три примерно одинаковые группы, каждая из групп содержит примерно по 2500 пользователей;
  • Две группы контрольные и им показывали старые шрифты, и одна экспериментальная, которой показывали новые шрифты;
  • Всего было 7534 пользователя;
  • Исследовано 5 основных событий, совершаемых пользователями:
    • Открытие главного экрана приложения - самое популярное событие,
    • Выбор продукта для заказа,
    • Переход в корзину,
    • Оплата заказа - ключевое событие.
  • В общей сложности совершено 241298 событий;
  • Исследование проводилось в течение недели - с 1 августа 2019 года по 7 августа 2019 года включительно;

Проведено статистическое тестирование, для определения, будут ли изменения влиять на пользоватей. Для этого было проведено:

  • Анализ событий;
  • А/А - тестирование для того, чтобы избежать ошибок анализа;
  • А/В - тестирование, для решения поставленной задачи;
  • Проведено исследование поправки на множественный тест.

По результатам тестирования установлено, что изменение шрифта в приложении не изменит отношения пользователей к приложению и не окажет влияния на ключевое событие. Таким образом, отдел дизайна может ввести свои изменения.

In [ ]: